JVM

JVM 虚拟机类加载机制

Posted by 余腾 on 2019-04-10
Estimated Reading Time 14 Minutes
Words 4k In Total
Viewed Times

一、虚拟机类加载机制是什么?

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。

二、类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,生命周期包括 7 个阶段:

  • (前5个阶段是在程序运行期间完成的 Runtime)

1、加载(Loading)
2、验证(Verification)
3、准备(Preparation)
4、解析(Resolution)
5、初始化(Initialization)


6、使用(Using)
7、卸载(Unloading)

其中:验证、准备、解析 -> 3个部分统称为 连接

类的加载过程必须按照这5个阶段顺序按部就班开始(而不是进行完成),而 解析 阶段则不一定:

  • 解析:在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(动态绑定),这些阶段通常是互相交叉地混合式进行。

类的生命周期,就是从类的加载到类实例的创建与使用,再到类对象不再被使用可以被GC卸载回收

  • 要注意一点,由java虚拟机自带的三种类加载器加载的类在虚拟机的整个生命周期中是不会被卸载的,只有用户自定义的类加载器所加载的类才可以被卸载。

主动引用

对类的加载的第一阶段,JVM规范并没有进行强制约束。对于初始化阶段,JVM规范则是严格规定了
有且仅有5种情况必须立即对类进行初始化(而加载、验证、准备自然需要在此之前开始)

  • 1、遇到 new、getstatic、putstatic 或 invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。

  • 2、使用java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。

  • 3、当初始化一个类的时,如果发现其父类还没有进行过初始化,则要先触发其父类的初始化。

  • 4、当虚拟机启动时,用户需要制定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

  • 5、当使用JDK 1.7的动态语言支持时,如果一个MethodHandle实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄、并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

这5种场景的行为称为对一个类进行主动引用。

除此之外,所有引用类的方式都不会触发初始化,称为被动引用。👇

被动引用 三个示例

被动引用示例之一

  • 以下代码只会输出”SuperClass init!” 而不会输出”SubClass init”。对于静态字段,只有直接定义这个字段的类才会被初始化。
  • 通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    //通过子类引用父类的静态字段,不会导致子类初始化

    public class SuperClass {

    static {
    System.out.println("SuperClass init!");
    }

    public static int value = 123;
    }

    public class SubClass extends SuperClass {
    static {
    System.out.println("SubClass init");
    }
    }

    public class NotInitialization {

    public static void main(String[] args) {
    System.out.println(SubClass.value);
    }
    }

被动引用示例之二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//通过数组定义来引用类,不会触发此类的初始化

public class SuperClass {

static {
System.out.println("SuperClass init!");
}

public static int value = 123;
}

public class SubClass extends SuperClass {
static {
System.out.println("SubClass init");
}
}

public class NotInitialization {

public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
}

被动引用示例之三

  • 以下代码没有输出”ConstClass init!”,因为虽然在Java源码中引用了ConstClass类中的常量HELLOWORLD,但其实在编译阶段通过常量传播优化,已经将常量的值”hello world” 存储到了NotInitialization类的常量池中。所以NotInitialization类对ConstClass.HELLOWORLD的引用,都被转化为NotInitialization对自身常量池的引用了。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    //常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类初始化。

    public class ConstClass {
    static {
    System.out.println("ConstClass init!");
    }

    public static final String HELLOWORLD = "hello world";
    }

    public class NotInitialization {

    public static void main(String[] args) {
    System.out.println(ConstClass.HELLOWORLD);
    }
    }

接口的加载过程与类的加载过程稍有一些不同,接口也有初始化过程:区别如下

  • 类有 static{}, 接口中不能使用 static{},但编译器仍然会为接口生成 “< clinit>()”类构造器用于初始化接口中所定义的成员变量。
  • 真正的区别在于类的5种情况的第 3 种:
    • 当一个类初始化时,要求其父类全部都已经初始过了,但是接口在初始化,并不要求其父接口全部都完成初始化。
    • 只有在真正使用到父接口的时候才会初始化。(如引用接口中定义的常量)

三、类加载的过程

1、加载(Loading)
2、验证(Verification)
3、准备(Preparation)
4、解析(Resolution)
5、初始化(Initialization)


1、加载(Loading)

加载是类加载(Class Loading) 过程的一个阶段。在加载阶段,虚拟机需要完成以下3件事情:

  • 1、通过一个类的全限定名来获取定义此类的二进制字节流。
  • 2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区此类各种数据访问入口。
  • 加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

2、验证(Verification)

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class文件的字节流中包含的的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段大致上会完成下面4个阶段的检查动作:

  • 1、文件格式的验证

    • 验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。后面的验证都是基于方法区的存储结构进行的不会再直接操作字节流
  • 2、元数据的验证

    • 对字节码描述的信息进行语义分析。主要目的是对类的元数据信息进行语义校验保证不存在不符合Java语言规范的元数据信息。
  • 3、字节码验证

    • 主要目的是通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。对类的方法体进行校验分析。
  • 4、符号引用验证

    • 最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候。这个转化动作将在连接的第三个阶段 解析 阶段中发生。

3、准备(Preparation)

  • 准备阶段是正式为类变量(静态变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
  • 这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。
  • 此时的 初始值 “通常情况下”是数据类型的 零值 。

public static int value = 123; —> 那变量 value 在准备阶段 过后的初始值为 0 而不是 123。

因为这时候尚未开始执行任何Java方法,而把value赋值为123的 putstatic 指令是程序被编译后,存放于类构造器< clinit>()方法之中,
所以把 value 赋值为 123 的动作将在初始化阶段才会执行。


然而如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量Value就会被初始化为 ConstantValue 属性所指定的值。

public static final int value = 123; —> 在准备阶段虚拟机就会根据 ConstantValue 的设置将 Value赋值为 123。


4、解析(Resolution)

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

符合引用

  • 符号引用时,JVM将使用StringBuilder来完成字符串的添加。
    1
    2
    String str =”adc”;
    System.out.println(“String =”+ str);

直接引用

  • 直接引用时则直接使用String来完成; 直接引用永远比符号引用效率更快,但实际应用开发中不可能全用直接引用。
    1
    System.out.println(“String =”+ “abc”);

解析有以下解析方式

  • 1、类或接口的解析
  • 2、字段解析
  • 3、类方法解析
  • 4、接口方法解析

5、初始化(Initialization)

  • 初始化阶段,才真正开始执行类中定义的Java程序代码。

初始化阶段是执行类构造器< clinit>()方法的过程。


1、< clinit>()方法:是由编译器自动收集类中的所有类变量(静态变量)的赋值动作和静态语句块(static{})中的语句合并产生的。

  • 编译器收集的顺序是由语句在源文件中出现的 顺序 所决定的。

2、< clinit>()方法与类的构造函数(或者说实例构造器< init>()方法)不同,它不需要显式地调用父类构造器。

  • 虚拟机会保证在子类的< clinit>()方法执行之前,父类的< clinit>()方法已经执行完毕。
  • 因此在虚拟机中第一个被执行的 < clinit>()方法的类肯定是 java.lang.Object
  • 由于父类的< clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

3、< clinit>()方法对于类或接口来说并不是必需的。

  • 如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成()方法。

4、接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成< clinit>()方法。

  • 但接口与类不同的是,执行接口的< clinit>()方法不需要先执行父接口的< clinit>()方法。
  • 只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的< clinit>()方法。

5、虚拟机会保证一个类的< clinit>()方法在多线程环境中被正确地加锁、同步。

  • 如果多个线程同时去初始化一个类,只会有一个线程去执行该类< clinit>()方法,其他线程都需要阻塞等待直到活动线程执行< clinit>()方法完毕。
  • 如果在一个类的< clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class jvm{
static {
i = 0;
//System.out.println(i);
// Cannot reference a field before it is defined(无法在定义字段之前引用该字段)
}
static int i = 1;

static class Parent {
public static int A = 1;

static {
A = 2;
}
}

static class Sub extends Parent {
public static int B = A;

public static void main(String[] args) {
System.out.println(Sub.B);// 因为第二条原则 所以输出为 2
}
}
}

四、类加载器


把类加载阶段中的 “通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。

实现这个👆动作的代码模块称为“类加载器”

类与类加载器

  • 比较两个类是否相等 只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

五、双亲委派模型

从Java虚拟机的角度来讲,只存在两种不同的类加载器:

  • 1、启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的部分;
  • 2、另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader

从Java开发人员的角度来看,绝大部分Java程序都会使用到以下3种系统提供的类加载器。

  • 1、启动类加载器(Bootstrap ClassLoader): 它负责将存放在<JAVA_ HOME>\lib目录中的类库加载到虚拟机内存中。

  • 2、扩展类加载器(Extension ClassLoader): 它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库。开发者可以直接使用扩展类加载器。

  • 3、应用程序类加载器(Application ClassLoader): 它负责加载用户类路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。


双亲委派模型概念

应用程序都是由这3种类加载器互相配合进行加载的,这些类加载器之间的关系一般如图所示。
类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)

  • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。
  • 这里类加载器之间的父子关系一般不会以继承(Inheritance) 的关系来实现,而是都使用组合(Composition) 关系来复用父加载器的代码。

双亲委派模型的工作过程

  • 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的 启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自已去加载。
  • 双亲委派模型对于保证Java程序的稳定运作很重要。*

六、破坏双亲委派模型

双亲委派模型并不是强制性的约束模型,而是Java设计者推荐给开发者的类加载器的实现方式。

双亲委派模型有三次较大规模的“被破坏”情况。

  • 第一次“ 被破坏 ”双亲委派模型出现之前 即JDK1.2发布之前。
  • 第二次“ 被破坏 ”是由于这个模型自身的缺陷所导致的。JNDI
  • 第二次“ 被破坏 ”是由于用户对程序动态性的追求导致的。 JSR-291(OSGI R4.2)

感谢阅读


If you like this blog or find it useful for you, you are welcome to comment on it. You are also welcome to share this blog, so that more people can participate in it. If the images used in the blog infringe your copyright, please contact the author to delete them. Thank you !